iT邦幫忙

2022 iThome 鐵人賽

DAY 10
2

波紋

來重現 MUI 的按鈕(包含波紋漣漪效果)吧。

不過因為實作動畫效果的原始碼比預期的長,故實心、外框與純文字按鈕的 CSS 實作內容放到明天來示範。

成品

展示
原始碼

開發思路

基本樣式

const defaultButtonStyle = css({
  position: 'relative',
  overflow: 'hidden',
  display: 'inline-flex',
  justifyContent: 'center',
  alignItems: 'center',
  cursor: 'pointer',
});

透過 position: relativeoverflow: hidden 來把漣漪動畫限制在「按鈕」的範圍內。
置中透過 display: inline-flex 來處理。
而在按鈕呈現 disable 狀態時,透過 pointer-events: none 來讓按鈕忽略點擊事件。

漣漪效果

點擊按鈕後,將一個圓形且逐漸擴大並變為透明的元件掛載到按鈕上,這個元件就是漣漪動畫效果的本體。
相關程式碼與解說如下:

// 動畫效果:設定 scale 讓漣漪放大,並同時透明度變為 0
const rippleAnimation = keyframes`
to {
  transform: scale(1.2);
  opacity: 0;
}
`;
// 設定按鈕為 overflow: hidden 把漣漪的範圍限制在按鈕之內
const defaultButtonStyle = css({
  position: 'relative',
  overflow: 'hidden',
  display: 'inline-flex',
  justifyContent: 'center',
  alignItems: 'center',
  cursor: 'pointer',
});
// 預設漣漪樣式,允許使用者透過 props.rippleColor 來直接控制漣漪顏色
const rippleStyle = useMemo(
  () =>
    css({
      position: 'absolute',
      borderRadius: '50%',
      backgroundColor: rippleColor,
      transform: 'scale(0)',
      animation: `${rippleAnimation} .7s ease`,
    }),
  [rippleColor]
);

const playRipple = useCallback(
  (e: MouseEvent): void => {
    // props.disableRipple 時,不執行任何關於漣漪動畫的計算
    if (disableRipple) return;

    // 根據點擊目標(也就是我們的按鈕元件)來計算漣漪的直徑,採用的是「按鈕元件長與寬比較大」的那一個數字
    const target = e.currentTarget as HTMLButtonElement;
    const diameter = Math.max(target.clientWidth, target.clientHeight);
    const radius = diameter / 2;

    // 透過 rippleContainerRef.current 來操作「負責裝載漣漪動畫的 span 元件」
    const rippleContainer = rippleContainerRef.current;
    if (rippleContainer) {
      const rippleEffect = document.createElement('span');

      // 設定漣漪的尺寸與漣漪動畫開始的位置
      rippleEffect.style.width = rippleEffect.style.height = `${diameter}px`;
      rippleEffect.style.left = `${e.clientX - (target.offsetLeft + radius)}px`;
      rippleEffect.style.top = `${e.clientY - (target.offsetTop + radius)}px`;

      // 加上漣漪的動畫樣式 rippleStyle
      rippleEffect.classList.add(rippleStyle);

      // 把漣漪 span 元件掛載到畫面上,而根據 rippleStyle 的設定,漣漪會在 0.7 秒後變為完全透明
      // 注意:動畫結束後,漣漪 span 元件還停留在 DOM 上,而我們需要在動畫結束後移除這個元件,否則多次點擊後會新增許多不必要的 span 元件
      rippleContainer.appendChild(rippleEffect);
    }
  },
  [disableRipple, rippleContainerRef, rippleStyle]
);
const removeRipple = useCallback((): void => {
  const rippleContainer = rippleContainerRef.current;
  if (rippleContainer) {
    rippleContainer.childNodes.forEach((node) => {
      // 檢查「負責裝載漣漪動畫的 span 元件」,若其中有 ELEMENT_NODE 且 class 包含 rippleStyle 的話,移除該「漣漪 span 元件」
      if (node.nodeType === 1) {
        const elementNode = node as HTMLElement;
        if (elementNode.classList.contains(rippleStyle)) {
          elementNode.remove();
        }
      }
    });
  }
}, [rippleContainerRef, rippleStyle]);

useEffect(() => {
  const button = buttonRef.current;
  button?.addEventListener('click', playRipple);
  // 漣漪動畫結束時,執行 removeRipple 來移除漣漪 span 元件
  button?.addEventListener('animationend', removeRipple);
  return () => {
    button?.removeEventListener('click', playRipple);
    button?.removeEventListener('animationend', removeRipple);
  };
}, [buttonRef, playRipple, removeRipple]);

修改指南

  • 調整漣漪綻放的速度(從小擴散到大):修改 rippleStyleanimation 的時間長短。
  • 調整漣漪最終綻放的大小:修改 rippleAnimationscale() 數字。

自評

漣漪效果沒有想像中容易,動手寫了才知道複雜。沒事不需要自幹,吃力不討好。
但不做動畫效果的話倒是蠻簡單的 (゚ ∀ ゚)

參考資料


上一篇
day09: Image
下一篇
day11: Button
系列文
我們可以不要 component library 了嗎?30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

2
Charlie
iT邦新手 5 級 ‧ 2022-09-21 13:44:33

有波紋就讚

d(`・∀・)b

我要留言

立即登入留言